Skip to content

Give types with an implicit numeric conversion Python's number protocol - #151

Open
jhonabreul wants to merge 7 commits into
QuantConnect:masterfrom
jhonabreul:feature-implicit-numeric-operators
Open

jhonabreul wants to merge 7 commits into
QuantConnect:masterfrom
jhonabreul:feature-implicit-numeric-operators

Conversation

@jhonabreul

Copy link
Copy Markdown
Collaborator

What does this implement/fix? Explain your changes.

Stacked on #150; the last two commits are this change.

A C# type with an implicit conversion to a number is usable as a number in C#, but not in Python:

unsupported operand type(s) for +: 'OrderFee' and 'OrderFee'
float() argument must be a string or a real number, not 'OrderFee'

Cause: Python evaluates a + b through type(a).__add__, pythonnet wires those slots from C# operator methods only, and an op_Implicit conversion is never consulted by an operator expression.

The change:

  • ImplicitNumericOps<T> is an [Ops] class like EnumOps<T>: arithmetic, comparison, unary negation, __float__ and __int__ methods delegating to the type's implicit numeric conversion.
  • ClassManager.GetClassInfo grafts those methods onto any type whose public static op_Implicit returns a numeric type, skipping operator names the type declares itself.
  • OperatorMethod.GetOperatorCandidates includes them so FixupSlots wires the number slots, and a new __float__ slot definition maps to nb_float.
  • Reverse comparisons are not synthesized; Python reflects 1 < x onto x > 1 by itself.

Does this close any currently open issues?

No.

Any other comments?

Tests, in TestImplicitNumericOperators:

  • Arithmetic between two convertible instances and against Python numbers on both sides, plus unary negation.
  • sum, max and sorted over convertible instances.
  • Forward and reflected comparisons against instances and numbers.
  • float() on a decimal-backed type, float() and int() on a double-backed type.
  • A type declaring its own + keeps it, while the undeclared - is synthesized.
  • A type without a conversion gets no __add__ or __float__.
  • Full embedding suite: 1009 passed, 0 failed.

… the instance

C# extension methods were only reachable from Python through their static
class, so instance-style calls such as order_status.is_open() failed with
AttributeError. Extension methods declared in non-framework assemblies are
now indexed during the assembly scan and bound as members of the type they
extend, including interface targets on implementing classes. The binder
passes the instance as the first argument when an extension method is called
through it. Real members with the same name win, and open generic targets are
skipped since the binder cannot infer them from the instance.
Python operators never consult C# implicit conversions, so a type convertible
to decimal or double still raised TypeError on addition, comparison and
float(). Such types now get arithmetic, comparison, __float__ and __int__
methods delegating to the conversion, grafted at class binding the same way
EnumOps is for enums. Operators the type declares itself are kept.
Martin-Molinero and others added 2 commits October 16, 2026 11:01
Targets made only of framework types are shared by countless classes:
extensions on object reached every CLR object, and Accord.Math alone added
~600 methods to each primitive array type. Across Lean's assemblies the
index shrinks from 16487 to 653 methods. Such extensions keep their static
form. Assemblies without the compiler's assembly-level [Extension] marker
are skipped up front, as C# does, and binding a class only walks its
interfaces when an interface target was indexed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Everything went through double, so integral conversions lost precision past
2**53 and returned floats where Python gives ints. Values now behave like the
Python number their conversion maps to: exact ints for integral conversions
(unbounded, like Python), floats for floating point and decimal ones.

- operands arrive as PyObject: ints beyond Int32 failed the binder's object
  conversion, and numbers implementing __index__ or __float__ (numpy scalars,
  other CLR types with a conversion) are read too; anything else returns
  NotImplemented so Python raises its usual TypeError
- division by zero raises ZeroDivisionError instead of returning infinity
- int() truncates decimals exactly and raises ValueError/OverflowError on
  NaN/infinity
- comparisons between instances of an IComparable type use its own ordering,
  as they did before the number protocol was added
- inherited operators keep winning over the synthesized ones, as in C#;
  OperatorMethod shares the filtered list, or the inherited slot recursed

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Martin-Molinero

Martin-Molinero commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Review follow-up: pushed 4444105, 62447d5 and ab9cc61

Where:

What and why

  1. Inherited operators were overridden. The filter checked local, which only holds names the type declares. If a base class declares op_Addition and the derived class adds the conversion, the grafted (T, T) overload was the closer match and won: d + d returned 4.0 instead of the base operator's result. C# would pick the base operator.
    • A first fix exposed a second bug: OperatorMethod.FixupSlots still installed nb_add on the derived type. That type then had no __add__ of its own, so CPython's slot wrapper called itself until RecursionError.
  2. Everything went through double.
    • int() on a type with an implicit long conversion lost precision: 2⁵³+1 came back as 9007199254740992.
    • Integer-backed types returned floats: OrderTicket has implicit operator int, so ticket + 1 gave 2.0.
    • Integer comparisons were off past 2⁵³.
  3. Division by zero returned inf. Python int/float raise ZeroDivisionError, and C# decimal throws.
  4. IComparable was overridden. tp_richcompare prefers operator methods over IComparable, so for types that have both, comparisons between two instances started going through double. Example: 1.0000000000000000000000000001m and 1m are equal as doubles, so a > b became False.

How

  • ImplicitNumericOps.GetMethods(type) now filters out operators the type declares or inherits. Both ClassManager and OperatorMethod.GetOperatorCandidates use this one list, which fixes items 1 and the slot recursion.
  • Values behave like the Python number their conversion maps to:
    • Integral conversions use exact BigInteger math and return Python ints. Results beyond long become a PyInt, so they're unbounded like Python ints. True division still returns a float.
    • float, double and decimal conversions return floats, as before.
  • The other operand is now taken as a PyObject and classified under the GIL. Two reasons:
    • The binder ranks double (precedence 3) above long (21), so typed (T, long)/(T, double) overloads would always produce floats.
    • An object parameter rejects any Python int beyond Int32. Converter's object path only tries Int32, because PyInt_Check catches every int before the Int64 branch. That's an existing converter bug worth fixing on its own. Without this change, fee + 2**31 would have regressed.
    • Accepted operands: instances of T, ints (including bool), floats, and anything with __index__ or __float__ (numpy scalars, other CLR types with a conversion). Anything else returns NotImplemented, so Python tries the other operand and raises its usual "unsupported operand" TypeError.
  • The GIL is taken explicitly, because this fork's binder always releases it ([ForbidPythonThreads] is disabled in MethodObject).
  • Division raises ZeroDivisionError("division by zero"). Returning inf instead would be a one-line change in Divide.
  • int() truncates decimals exactly, and raises ValueError/OverflowError on NaN/infinity, like Python.
  • Comparisons between instances of an IComparable type use Comparer<T>.Default. Comparisons against numbers stay numeric.

Performance: both versions were timed with the same embed test. Arithmetic went from about 1.7–1.8 µs to 1.2 µs per op, and comparisons from 2.8 µs to 1.6 µs, because there's now one overload per direction instead of three. A plain property read is about 0.2 µs for reference. These timings are rough: the full suite was running at the same time.

Tests

  • TestImplicitNumericOperators: 15/15. The 6 existing tests are unchanged and still pass. New tests cover:
    • int results for integral conversions, and floats where Python would give them
    • exact values past 2⁵³ and past long
    • int() on NaN, infinity and large decimals
    • ZeroDivisionError
    • ints beyond Int32 and other numeric types as operands
    • the TypeError for unsupported operands
    • inherited operators winning, and IComparable ordering winning
  • Full embed suite: 1020 passed, 0 failed (net10.0, Python 3.11), on the full stack including ab9cc61.
  • Lean Python regression algorithms: 339/339 passed with this branch's build (2.0.68) dropped into Lean master 154e50720c, following .github/workflows/lean-python-regression-tests.yml.

🤖 Generated with Claude Code

…form

[EditorBrowsable(EditorBrowsableState.Never)] hides a member from
IntelliSense while keeping it callable. Binding such extension methods to
the instance would advertise them anyway, so those methods, and all the
extension methods of a class marked so, keep their static form only. It
lets a library hide plumbing helpers, like Lean's IAlgorithm extensions,
from the instance with a standard attribute.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants